浅谈Tangram,像七巧板一样快速搭建页面
本文作者
作者:三雒
链接:
https://juejin.cn/post/7263705320768684089
本文由作者授权发布。
写在前面
RecyclerView自带的LayoutManager太鸡肋了,实现稍微杂一点的信息流页面需要写很多管理ViewHolder类型和占用span数量的代码。 代码太冗余了,在双列瀑布流头部添加一个四列的卡片,需要ViewHolder内部再套一个RecyclerView,再为里面的RecylerView实现Adapter和ViewHolder, 开发效率极低。 滑动页面滑动到某个位置Sticky略微有点难度,需要搞懂嵌套滑动机制、CoordinatorLayout这些玩意儿;实现悬浮可拖动按钮,Banner, 多列,一拖N布局,好像都不太难,但是我只想写好我的业务代码,哪有时间去写这些花里胡哨的东西。 PM天天想在线上调整页面结构,线上修改布局样式,可是你无奈的告诉她 ”做不到“。
现在我告诉你这些问题Tangram通通都能解决,并且它都在一个RecyclerView内部就能完成,去它丫的StaggedGridLayoutManager,去它丫的CoordinatorLayout和嵌套滑动,去它丫的自定义View,再也不用为拒绝可爱的PM而烦恼了。
我更愿意称Tangram是针对电商首页、搜索结果页等综合信息流页面的一套粗粒度动态化方案,Tangram的核心其实是灵活的布局能力加上一定的动态能力,而动态性其实本身就要求其灵活性,粗粒度则表示其动态能力没有那么强。
页面指的就是整体可滑动页面实体,比如下图整个就是一个页面。 卡片指的是页面内可按行划分的一个一个独立区块,比如开头的单列、整个Banner区域、瀑布流区域等。 组件指的是卡片内部一个独立的、业务级别的单元,可以粗略认为是RecyclerView中最小粒度的ViewHolder, 比如单列中一行, Banner内的一张图等等。
灵活性
动态性
高性能
个人认为并不能有效提升渲染性能,原因在于Android View的渲染流程分为measure,layout和draw三大步,主要耗时在measure和layout, draw在主线程的耗时很少,主要是由RenderThread线程来做。而VV只是减少绘制的时候的层级,并不能减少measure和layout的耗时。
双端一致性
Tangram的这些特性描述有些理解的可能不是特别清楚,没关系,接下来核心能力部分这些特性都会有进一步的阐释。
刀耕火种
需求1.0: 如下的页面中包含单列、双列、多列网格、一拖N、Banner、吸顶等布局卡片等,滑动到最后是双列,且可以一直滚动加载更多。
整体使用RecylerView+GridLayoutManager来实现,GridLayoutManager的列数为2,满足最后滚动加载的双列。 上面所有占满屏幕的卡片比如Banner、一拖N、多列卡片都需要使用SpanSizeLookup控制这些卡片占用span数为2。 在Adapter中管理各种卡片类型,实现外部单个卡片的ViewHolder以及对应的View和业务逻辑。对于一些类型的卡片我们需要嵌套一层View作为卡片的根布局,比如多列网格、一拖N、Banner等。以多列网格为例,会使用RecyclerView+GridLayoutManager来实现,然后就需要再为卡片内部的每一个组件实现对应的ViewHolder。
开发效率低,更希望的是开发者能够专注于卡片内每个组件的业务逻辑实现,而不必在卡片本身的布局实现、类型管理、占用span数量等投入过多精力。 性能差,主要体现在滑动和复用两个方面。RecyclerView会在滑动过程中进行创建ViewHolder, 绑定数据并进行渲染,当滑动到图中的双列时候整个卡片的高度会根据数据确定并一次性measure和layout所有双列卡片的所有组件,会让这一帧很卡;另外在一些情况下不同的卡片可能会使用相同的组件,这时候是希望这些组件能够进程复用的,但是由于跨RecyclerView,本身是不复用的。
效率性能提升(vlayout)
需求2.0: 把最上面单列展示的卡片转改成一拖N的卡片,把StaggerCard部分改成一个三列,另外还一直会有新增的业务卡片。
自定义了一个VirtualLayoutManager,它继承自 LinearLayoutManager;引入了 LayoutHelper 的概念,它负责具体的布局逻辑;VirtualLayoutManager管理了一系列LayoutHelper,将具体的布局能力交给LayoutHelper来完成,每一种LayoutHelper提供一种布局方式,框架内置提供了几种常用的布局类型,包括:网格布局、线性布局、瀑布流布局、悬浮布局、吸边布局等。这样实现了混合布局的能力,并且支持扩展外部,注册新的LayoutHelper,实现特殊的布局方式。 提供了自定义的布局样式,可以满足多样化的布局需求,比如每一个组件范围内的布局支持一个背景颜色、背景图片;网格布局里,可以支持1列、2列、3列、4列、5列共5种样式,每一列的宽度默认平均分配屏幕宽度,也可以指定按比例分配列宽。吸边布局支持吸到屏幕底部、屏幕顶部、屏幕左边、屏幕右边。这些都是系统默认的LayoutManager不支持的。 每一种LayoutHelper负责布局一批组件范围内的组件,不同组件范围内的组件之间,如果类型相同,可以在滑动过程中回收复用。因此回收粒度比较细,且可以跨布局类型复用。
使用示例:
public class MyAdapter extends VirtualLayoutAdapter {
...
}
MyAdapter myAdapter = new MyAdapter(layoutManager);
//构造 layoutHelper 列表
List<LayoutHelper> helpers = new LinkedList<>();
GridLayoutHelper gridLayoutHelper = new GridLayoutHelper(4);
gridLayoutHelper.setItemCount(10);
helpers.add(gridLayoutHelper);
GridLayoutHelper gridLayoutHelper2 = new GridLayoutHelper(2);
gridLayoutHelper2.setItemCount(10);
helpers.add(gridLayoutHelper2);
//将 layoutHelper 列表传递给 adapter
myAdapter.setLayoutHelpers(helpers);
//将 adapter 设置给 recyclerView
recycler.setAdapter(myAdapter);
页面布局流程:
RecyclerView是整个页面的主体,它的运行需要绑定一个Adapter和LayoutManager,在我们的设计里自定义了VirtualLayoutAdapter和VirtualLayoutManager来绑定到RecyclerView。 VirtualLayoutAdapter继承自系统的Adaper,它在原生地Adapter上扩展了两个接口:setLayoutHelpers()——业务方调用此方法设置整个页面所需要的一系列LayoutHelper; getLayoutHelpers()——与setLayoutHelpers()对应;这两个方法的具体实现都委托给VirtualLayoutManager来完成。 VirtualLayoutManager继承自系统的 LinearLayoutManager,在RecyclerView加载组件或者滑动的时候,会调用VirtualLayoutManager的layoutChunck方法,告诉它当前还有哪些空白区域可以用来摆放组件, VirtualLayoutManager会持有一个LayoutHelperFinder,当layoutChunck被调用的时候,会传入一个位置参数,告诉LayoutManager当前要布局第几个组件,LayoutHelperFinder就通过这个位置找到当前这个位置对应的LayoutHelper,因为每个LayoutHelper都会绑定它负责的布局区域的起始位置和结束位置。 LayoutHelper负责具体的布局逻辑,它有一系列子模块,其中基类LayoutHelper定义了一系列接口,用来和VirtualLayoutManager通信,包括isOutOfRange()——告诉VirtualLayoutManager它所传递过来位置是否在当前LayoutHelper的布局区域内;setRange()——设置当前LayoutHelper负责的布局区域;beforeLayout()——在真正布局之前做一些前置工作;doLayout()——真正的布局逻辑接口;afterLayout()——在布局完成之后做一些后置工作;MarginLayoutHelper稍微扩展LayoutHelper,提供了布局常用的内边距padding、外边距margin的计算功能;BaseLayoutHelper是第一层具体实现,实现了当前LayoutHelper在屏幕范围内的具体区域,用于填充对这一区域填充背景色、背景图等逻辑。而剩下的LinearLayoutHelper、GridLayoutHelper等负责了具体的布局逻辑,它们都重点实现了beforeLayout()、doLayout()、afterLayout()方法,特别是在doLayout()方法里,会获取一个一组件,按照各自的协议对组件进行尺寸计算、界面布局。框架内置了以下几种重要的 LayoutHelper:
LinearLayoutHelper,实现简单的线性布局; GridLayoutHelper,实现网格布局,支持1-5列的网格,支持配置列间距、行间距,支持不等宽的网格; StaggeredLayoutHelper,实现瀑布流式的布局; FloatLayoutHelper,负责悬浮效果,处于该布局中的组件会悬浮在整个页面上方,并且可拖拽,不随页面滚动而滚动; FixedLayoutHelper,负责固定位置的布局,它可固定在屏幕某个位置,不可拖拽,不随页面滚动而滚动; StickyLayoutHelper,它是一种吸边的布局,当它包含的组件处于屏幕可见范围内的时候,像正常的组件一样随页面滚动而滚动,当组件将要被滑出屏幕返回的时候,可以吸到屏幕的顶部或者底部,实现一种吸住的效果;
页面结构动态化(Tangram)
需求3.0: 要求能在线上对任意业务卡片的形态或者样式等做出调整,卡片的形态主要就是vlayout支持的种类,样式也基本满足。
数据设计
[ {
"type": "container-twoColumn",
"style": {
"hGap": 10, // 卡片水平间距
"vGap": 10, // 卡片垂直间距
"cols": [
35.5
]
},
"items": [
{
"type": 1
},
...
]
},
{
"type": "container-onePlusN",
"style": {
"aspectRatio": "1.778",
"cols": [
43.467
],
"rows": [
43.602
]
},
"items": [
{
"type": "10",
"action": "xxx",
"imgUrl": "https://gw.alicdn.com/tfs/TB1pdJFQpXXXXbUXpXXXXXXXXXX-750-243.png",
"style": {
"margin": "[0,1,0,0]"
}
},
...
]
}
}]
渲染页面流程
不论是传递原始 JSON 数据给 TangramEngine还是通过直接解析原始数据,都是通过 DataParser 来完成的,它会按照树型结构解析出对应的卡片和组件的 Model 对象,解析过程依赖于相应的卡片 Resolver 和组件 Resolver 来识别卡片、组件是否已注册,关键点就是识别 type 字段。若碰到无法识别的 type,则不会解析出对应的 model 对象。 解析完成之后会得到一个卡片列表,每个列表的卡片 Model 元素里持有它所包含的组件列表。 Model 列表交给 GroupBasicAdapter 进行处理,首先提取卡片列表,将包含空组件列表的卡片过滤掉,因为它没有东西可以渲染展示,然后创建出 vlayout 所需要的 LayoutHelper 列表,设置它们的样式属性,这样就打通了通过 JSON 数据最终控制布局排版的流程。 将所有的组件 Model 提取出来成为一个独立的列表,真正交给 GroupBasicAdapter 去渲染数据,组件 Model 列表的大小就是 GroupBasicAdapter 的 item 的大小, RecyclerView 也就直接加载组件视图,卡片相对于只负责了布局逻辑的控制,并没有 UI 实体的承载。 数据都准备完毕之后,RecyclerView 就驱动 vlayout 里的 VirtualLayoutManager 进行渲染和布局。 VirtualLayoutManager 首先回调 RecyclerView 内部获取 ViewHolder,若复用池里存在复用的对象,就回调 GroupBasicAdapter 进行数据绑定,否则先回调 GroupBasicAdapter 进行组件 ViewHolder 的创建,然后进行数据绑定。ViewHolder 的创建也是通过 Resolver 内部创建 UI 的模块进行构造。
组件布局动态化 (VirtualView)
需求4.0:在线上任意修改组件布局样式,也可以动态新增新的组件。
使用流程
先编写业务组件的模板。 通过工具将模板数据编译成二进制数据。 客户端加载二进制数据可以有两种路径,一是直接打包到客户端里,另一种是发布到模板管理后台,客户端在线更新到模板数据。 不论哪种方式加载二进制数据,客户端接下来的工作是解析二进制数据里,比如校验版本号,合法性,读取头信息等等。 等要真正创建组件的时候,根据组件名称找到二进制数据,从中解析并创建出真正的组件模型数据。 从模板里创建在组件往往不含有业务数据,因为业务数据是动态性的,用户需要获取到业务数据绑定到组件上,组件的属性里可以写表达式来指定使用哪一个数据字段。
<?xml version="1.0" encoding="utf-8"?>
<VHLayout
flag="flag_exposure|flag_clickable"
orientation="H"
action="${action}"
layoutWidth="match_parent"
layoutHeight="wrap_content">
<NImage
id="1"
src="${logoUrl}"
layoutMarginLeft="8"
layoutMarginRight="8"
layoutMarginTop="8"
layoutMarginBottom="8"
layoutWidth="32"
layoutHeight="32"/>
<NText
id="2"
text="${title}"
layoutGravity="v_center"
gravity="${style.text-align}"
textSize="${style.font-size}"
textColor="${style.color}"
layoutWidth="match_parent"
layoutHeight="wrap_content"/>
</VHLayout>
{
"style": {
"text-align": "h_center",
"font-size": "20",
"color": "#FF5000"
},
"title": "超高性 99.9% 的用户觉得很快",
"logoUrl": "https://gw.alicdn.com/tfs/TB1yGIdkb_I8KJjy1XaXXbsxpXa-72-72.png",
"action":""
}
数据绑定
访问数据属性EL表达式
${benefitImgUrl}
${data[0].benefitImgUrl}
条件表达式(三元操作符)
@{${logoUrl} ? visible : invisible }
事件管理
vafContext.getEventManager.register(EventManager.TYPE_Click) {
val action = eventData.mVB.action
RouterManager.open(action);
true
}
基础组件(控件)
即通过Android原生的View组件或者布局容器实现,它需要继承自ViewBase,不过内部都是调用对应原生View的逻辑。
虚拟组件,即View的内容直接通过宿主的canvas绘制出来,它本身并不需要一个实体的View存在,在真实的View Tree中,是看不到这个实例,只能看到其宿主的存在。它包括原子虚拟View组件比如文本、图片、线条,布局虚拟view组件比如线性布局、帧布局等。虚拟组件也遵循Android绘制View的逻辑,需要响应measure、layout、draw的过程才能显示。
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
flag="flag_exposure|flag_clickable"
action="action"
layoutWidth="match_parent"
layoutHeight="200rp"
background="#FFFFFF">
<NImage
layoutWidth="48rp"
layoutHeight="48rp"
layoutMarginLeft="34rp"
layoutGravity="left|v_center"
src="${imgUrl}"
scaleType="center_crop"
/>
<VText
layoutWidth="wrap_content"
layoutHeight="wrap_content"
layoutMarginLeft="94rp"
text="${title}"
layoutGravity="left|v_center"
textSize="28rp"
textColor="#555555"
/>
<VText
layoutWidth="wrap_content"
layoutHeight="wrap_content"
layoutMarginRight="50rp"
layoutGravity="right|v_center"
textSize="28rp"
text="${subtitle}"
textColor="${subtitleColor}"/>
<NImage
layoutWidth="14rp"
layoutHeight="30rp"
layoutMarginRight="28rp"
layoutGravity="right|v_center"
src="${arrowImgUrl}"
scaleType="center_crop"
/>
</FrameLayout>
二进制文件格式
容器层:最底层是页面层级的容器是RecyclerView,这也是整个框架的基石。从功能上看一个支持滚动的容器,并且支持布局方式的行定义,从性能上看自带ViewHolder的回收复用机制。 卡片/组件实现层:这一层主要提供卡片或者组件的实现支持,vlayout中VirtualLayoutManager及LayoutHelper提供了卡片布局支持,VirtualView则提供了动态组件的支持,当然我们也可自定义ViewHolder实现原生的卡片。 卡片/组件Model层:主要将卡片和组件能力动态化,Card包含了一个布局类型和样式的信息,和LayoutHelper对应;Cell 包含一个组件的信息,PojoGroupAdapter负责页面数据信息管理、ViewHolder创建与数据绑定。 数据解析层:DataParser 负责解析数据,它将原始数据解析成Card、Cell等。; Resolver 负责识别卡片、组件并构建对象,解析器解析数据的时候需要依赖这些 Resolver去识别数据中的卡片或者组件是否合法。 Engine: TangramEngine是核心类,它负责给 vlayout 绑定RecyclerView、绑定页面数据、操作页面数据,创建卡片、组件等。Tangram采用服务发现机制提供服务,TangramEnginge本身实现了ServieManager,不论是内部还是外部功能模块,都可以注册到这里。一方面能被 Tangram 内部的其他模块访问使用,另一方面解耦了框架与业务模块。 用户服务:Tangram对外提一些服务,BusSupport用于通信,类似EventBus;ClickSupport 用于给用户统一处理点击时间;ExposureSupport 用于组件的曝光统计;CellSupport用户监控组件的一些生命周期等。它们都被注册到 ServiceManager 里,业务方在组件或者页面内都可以使用他们。
Tangram是一个粗粒度动态化框架。 Tangram是构建在一个可滑动的页面模型上,层级时候页面--卡片--组件,在Android的架构大致对应到RecyclerView--LayoutHelper--ViewHolder。 Tangram的灵活性体现在支持多列、瀑布流、一拖N、Sticky等各种布局方式;动态性体现在页面结构和组件布局是支持动态调整的;性能体现在组件级别的回收复用机制和引入虚拟组件提升页面渲染效率。 Tangram支持丰富的布局格式,能够有效提升开发效率;Tangram支持页面结构和组件样式的动态调整;Tangram的性能和原生开发性能几乎一致。
参考文档
Tangram官网
http://tangram.pingguohe.net/docs/basic-concept/history
VirtualView基本原理
http://tangram.pingguohe.net/docs/virtualview/bin-format
猫客页面内组件的动态化方案-Tangram
https://juejin.cn/post/6844903520315899918#heading-14
最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
推荐阅读:
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!